int counter = 0;
// Атомарное увеличение
Interlocked.Increment(ref counter);
// Атомарное уменьшение
Interlocked.Decrement(ref counter);
Для атомарных операций над примитивами, работает на уровне команд процессора, поэтому только примитивы
Компилятор имеет свойство менять местами операции в коде, а также проводить другие оптимизации, например погрузку переменных в регистры, данное свойство может сломать логику работы при многопоточке. (Более того, даже процессор такую штуку мутит)
Для предотвращения вредных оптимизаций существуют блокираторы памяти, они есть на уровне ассемблерных команд, а разработчик Dotnet может ими управлять с помощью ключевого слова Volatile
Пример аномалии, вызванной попаданием переменной в регистр, хотя она изменяется другим потоком. В теории код должен завершится, но он будет вечен.
private static int t;
static void Main()
{
var e = Task.Run(f);
Thread.Sleep(1000);
t = 1;
e.Wait();
}
static void f()
{
t = 0;
while (t == 0)
{
}
}
Если добавить к переменной ключевое слово volatile то код штатно отработает и завершится через +- секунду.
Существуют аномалии вызванные перестановкой инструкций программы, для начала представлю табличку, где описано какие инструкции могут поменяться местами. Процесс загрузки это чтение из памяти, а запись это запись
Тип перестановки | Перестановка разрешена |
Загрузка-загрузка | Да |
Загрузка-запись | Да |
Запись-загрузка | Да |
Запись-запись | Нет |
Для запрета перестановки инструкций, существуют барьеры памяти, их несколько видов
Термин volatile write означает выполнение записи в память в сочетании с созданием release fence
Термин volatile read означает чтение памяти в сочетании с созданием accure fence.
.NET предоставляет следующие методы работы с барьерами памяти:
Рассмотрим, как реализованы эти методы:
private struct VolatileByte { public volatile byte Value; }
[Intrinsic]
[NonVersionable]
public static byte Read(ref byte location) =>
Unsafe.As<byte, VolatileByte>(ref location).Value;
[Intrinsic]
[NonVersionable]
public static void Write(ref byte location, byte value) =>
Unsafe.As<byte, VolatileByte>(ref location).Value = value;
Как можно заметить, на момент dotnet 6, эти методы работают избыточно, то есть, создается переменная с ключевым словом volatile и происходит чтение и запись сначала в неё, однако, как известно, volatile обеспечивает одновременно оба уровня защиты, а значит текущая реализация избыточна.
Также, стоит заметить, что ключевое слово volatile не дает полной защиты (как это делает полный барьер), тк эти две операции все равно могут быть переставлены без нарушения их условий:
Thread.VolatileWrite(b)
Thread.VolatileRead(a)
Иногда, есть необходимость, чтобы переменная хранилась в рамках одного контекста выполнения, например при обработке запроса из API. Раньше, до async/await для этого применялся атрибут
[ThreadStatic]
private static StringBuilder CachedInstance;
В таком случае, для каждого потока создается отдельная статическая переменная. Но с применением async/await выполнение программы может продолжить другой поток. Для сохранения переменной в рамках контекста выполнения между потоками применяется конструкция
private static AsyncLocal<string> _context = new AsyncLocal<string>();
При применении такого типа, на этапе выполнения, при переключении потока в стейтмашине, сохраняются все такие переменные в екземпляре класса ExecutionContext (не путать с контекстом потока ОС, этот контекст на уровне CLR). А при запуске снова стейтмашины, этот экземпляр ExecutionContext подбирается потоком и нужные значения присваиваются.
Данную конструкцию удобно применять например при логгировании в обработке Http